系列文章: 前端工程師的 Modern Web 實踐之道 - Day 9
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆
在前一篇文章中,我們探討了元件化思維的設計哲學。今天我們將從更宏觀的角度來思考——如何組織大型前端應用的程式碼結構,這個架構設計將直接影響專案的長期維護成本和團隊協作效率。
讓我們先看看一個真實專案是如何從簡單變複雜的:
# 階段1:單純的靜態網站 (Day 1-30)
my-project/
├── index.html
├── style.css
├── script.js
└── images/
# 階段2:加入框架和構建工具 (Day 30-180)
my-project/
├── public/
├── src/
│ ├── components/
│ ├── pages/
│ ├── utils/
│ ├── App.js
│ └── index.js
├── package.json
└── webpack.config.js
# 階段3:業務複雜化 (Day 180-365)
my-project/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ ├── Modal/
│ │ ├── Form/
│ │ └── ... (50+ 元件)
│ ├── pages/
│ │ ├── Dashboard/
│ │ ├── UserManagement/
│ │ ├── Reports/
│ │ └── ... (20+ 頁面)
│ ├── services/
│ ├── utils/
│ ├── hooks/
│ ├── contexts/
│ └── constants/
└── ... (其他配置檔案)
問題開始浮現:
成功的大型前端應用都遵循以下核心原則:
1. 關注點分離(Separation of Concerns)
// ❌ 混雜的關注點
class UserDashboard {
// UI 渲染邏輯
render() { /* ... */ }
// 業務邏輯
calculateUserScore() { /* ... */ }
// 資料存取邏輯
fetchUserData() { /* ... */ }
// 表單驗證邏輯
validateForm() { /* ... */ }
}
// ✅ 清晰的關注點分離
// 業務邏輯層
class UserService {
calculateUserScore(user: User): number { /* ... */ }
validateUserData(data: UserData): ValidationResult { /* ... */ }
}
// 資料存取層
class UserRepository {
async fetchUser(id: string): Promise<User> { /* ... */ }
async updateUser(user: User): Promise<void> { /* ... */ }
}
// 展示層
class UserDashboard {
constructor(
private userService: UserService,
private userRepository: UserRepository
) {}
async render() { /* 只負責 UI 渲染 */ }
}
2. 依賴反轉(Dependency Inversion)
// ❌ 高層模組依賴低層模組
class OrderService {
private httpClient = new HttpClient(); // 直接依賴具體實作
async createOrder(order: Order): Promise<void> {
await this.httpClient.post('/orders', order);
}
}
// ✅ 依賴抽象而非具體實作
interface ApiClient {
post<T>(url: string, data: any): Promise<T>;
get<T>(url: string): Promise<T>;
}
class OrderService {
constructor(private apiClient: ApiClient) {} // 依賴抽象
async createOrder(order: Order): Promise<void> {
await this.apiClient.post('/orders', order);
}
}
// 具體實作可以輕易替換
class HttpApiClient implements ApiClient {
async post<T>(url: string, data: any): Promise<T> { /* HTTP 實作 */ }
async get<T>(url: string): Promise<T> { /* HTTP 實作 */ }
}
class MockApiClient implements ApiClient {
async post<T>(url: string, data: any): Promise<T> { /* Mock 實作 */ }
async get<T>(url: string): Promise<T> { /* Mock 實作 */ }
}
# 現代化大型專案架構 (推薦)
src/
├── shared/ # 共用模組
│ ├── components/ # 共用元件
│ │ ├── ui/ # 基礎 UI 元件
│ │ │ ├── Button/
│ │ │ ├── Input/
│ │ │ └── Modal/
│ │ └── business/ # 業務共用元件
│ │ ├── UserAvatar/
│ │ └── StatusBadge/
│ ├── services/ # 共用服務
│ │ ├── api/
│ │ ├── auth/
│ │ └── storage/
│ ├── utils/ # 工具函式
│ ├── types/ # 型別定義
│ └── constants/ # 常數定義
├── features/ # 功能模組
│ ├── user-management/ # 使用者管理功能
│ │ ├── components/
│ │ ├── services/
│ │ ├── types/
│ │ ├── utils/
│ │ └── index.ts # 模組匯出
│ ├── dashboard/ # 儀表板功能
│ ├── reporting/ # 報表功能
│ └── settings/ # 設定功能
├── app/ # 應用層
│ ├── store/ # 全域狀態管理
│ ├── router/ # 路由設定
│ ├── layouts/ # 版面配置
│ └── providers/ # 供應者元件
└── assets/ # 靜態資源
// features/user-management/index.ts - 模組匯出檔案
export { UserManagementPage } from './components/UserManagementPage';
export { UserService } from './services/UserService';
export { useUsers } from './hooks/useUsers';
export type { User, UserFormData } from './types';
// features/user-management/components/UserManagementPage.tsx
import { UserList } from './UserList';
import { UserForm } from './UserForm';
import { useUsers } from '../hooks/useUsers';
export function UserManagementPage() {
const { users, createUser, updateUser, deleteUser, loading } = useUsers();
return (
<div className="user-management">
<UserForm onSubmit={createUser} />
<UserList
users={users}
onUpdate={updateUser}
onDelete={deleteUser}
loading={loading}
/>
</div>
);
}
// features/user-management/services/UserService.ts
import { ApiClient } from '../../../shared/services/api';
import { User, CreateUserRequest } from '../types';
export class UserService {
constructor(private apiClient: ApiClient) {}
async getUsers(): Promise<User[]> {
return this.apiClient.get<User[]>('/users');
}
async createUser(userData: CreateUserRequest): Promise<User> {
return this.apiClient.post<User>('/users', userData);
}
async updateUser(id: string, userData: Partial<User>): Promise<User> {
return this.apiClient.put<User>(`/users/${id}`, userData);
}
async deleteUser(id: string): Promise<void> {
return this.apiClient.delete(`/users/${id}`);
}
}
// features/user-management/hooks/useUsers.ts
import { useState, useEffect } from 'react';
import { UserService } from '../services/UserService';
import { User, CreateUserRequest } from '../types';
export function useUsers() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const userService = new UserService(/* 注入依賴 */);
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
setError(null);
const users = await userService.getUsers();
setUsers(users);
} catch (err) {
setError(err instanceof Error ? err.message : '載入失敗');
} finally {
setLoading(false);
}
};
const createUser = async (userData: CreateUserRequest) => {
try {
const newUser = await userService.createUser(userData);
setUsers(prev => [...prev, newUser]);
return newUser;
} catch (err) {
setError(err instanceof Error ? err.message : '建立失敗');
throw err;
}
};
return {
users,
loading,
error,
createUser,
updateUser: (id: string, data: Partial<User>) =>
userService.updateUser(id, data).then(loadUsers),
deleteUser: (id: string) =>
userService.deleteUser(id).then(loadUsers),
refreshUsers: loadUsers
};
}
// shared/services/api/ApiClient.ts
export interface ApiClient {
get<T>(url: string, config?: RequestConfig): Promise<T>;
post<T>(url: string, data?: any, config?: RequestConfig): Promise<T>;
put<T>(url: string, data?: any, config?: RequestConfig): Promise<T>;
delete<T>(url: string, config?: RequestConfig): Promise<T>;
}
export interface RequestConfig {
headers?: Record<string, string>;
timeout?: number;
retries?: number;
}
// shared/services/api/HttpApiClient.ts
import { ApiClient, RequestConfig } from './ApiClient';
export class HttpApiClient implements ApiClient {
constructor(
private baseURL: string,
private defaultConfig: Partial<RequestConfig> = {}
) {}
async get<T>(url: string, config?: RequestConfig): Promise<T> {
return this.request<T>('GET', url, undefined, config);
}
async post<T>(url: string, data?: any, config?: RequestConfig): Promise<T> {
return this.request<T>('POST', url, data, config);
}
private async request<T>(
method: string,
url: string,
data?: any,
config?: RequestConfig
): Promise<T> {
const fullConfig = { ...this.defaultConfig, ...config };
try {
const response = await fetch(`${this.baseURL}${url}`, {
method,
headers: {
'Content-Type': 'application/json',
...fullConfig.headers
},
body: data ? JSON.stringify(data) : undefined
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
return await response.json();
} catch (error) {
if (error instanceof ApiError) throw error;
throw new ApiError(0, '網路錯誤');
}
}
}
export class ApiError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
// shared/components/ui/Button/Button.tsx
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
loading?: boolean;
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'medium',
loading = false,
disabled = false,
children,
onClick
}: ButtonProps) {
const baseClasses = 'btn';
const variantClasses = {
primary: 'btn-primary',
secondary: 'btn-secondary',
danger: 'btn-danger'
};
const sizeClasses = {
small: 'btn-sm',
medium: 'btn-md',
large: 'btn-lg'
};
const classes = [
baseClasses,
variantClasses[variant],
sizeClasses[size],
loading && 'btn-loading',
disabled && 'btn-disabled'
].filter(Boolean).join(' ');
return (
<button
className={classes}
disabled={disabled || loading}
onClick={onClick}
>
{loading && <Spinner size="small" />}
{children}
</button>
);
}
// tools/dependency-checker.ts
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
interface ModuleDependency {
module: string;
dependencies: string[];
}
class DependencyAnalyzer {
private modules: Map<string, string[]> = new Map();
analyzeProject(srcPath: string): void {
this.scanDirectory(srcPath);
this.detectCircularDependencies();
this.validateArchitectureRules();
}
private scanDirectory(dirPath: string): void {
const items = readdirSync(dirPath);
for (const item of items) {
const fullPath = join(dirPath, item);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
this.scanDirectory(fullPath);
} else if (item.endsWith('.ts') || item.endsWith('.tsx')) {
this.analyzeFile(fullPath);
}
}
}
private analyzeFile(filePath: string): void {
const content = readFileSync(filePath, 'utf-8');
const imports = this.extractImports(content);
this.modules.set(filePath, imports);
}
private extractImports(content: string): string[] {
const importRegex = /import.*from\s+['"]([^'"]+)['"]/g;
const imports: string[] = [];
let match;
while ((match = importRegex.exec(content)) !== null) {
if (match[1].startsWith('./') || match[1].startsWith('../')) {
imports.push(match[1]);
}
}
return imports;
}
private detectCircularDependencies(): void {
// 實作循環依賴檢測邏輯
const visited = new Set<string>();
const stack = new Set<string>();
for (const [module] of this.modules) {
if (!visited.has(module)) {
this.dfsCircularCheck(module, visited, stack, []);
}
}
}
private validateArchitectureRules(): void {
// 檢查架構規則,例如:
// - shared 模組不能依賴 features 模組
// - features 模組不能互相依賴
for (const [module, dependencies] of this.modules) {
if (module.includes('/shared/')) {
const invalidDeps = dependencies.filter(dep =>
dep.includes('/features/')
);
if (invalidDeps.length > 0) {
console.error(`架構違規: shared 模組 ${module} 依賴了 features 模組`);
}
}
}
}
}
// webpack.config.js - 智能程式碼分割
const path = require('path');
module.exports = {
entry: './src/index.ts',
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// 第三方套件
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10
},
// 共用模組
shared: {
test: /[\\/]src[\\/]shared[\\/]/,
name: 'shared',
chunks: 'all',
priority: 5,
minChunks: 2
},
// 各功能模組
features: {
test: /[\\/]src[\\/]features[\\/]/,
name(module) {
// 根據功能目錄自動命名
const match = module.context.match(/features[\\/]([^[\\/]]+)/);
return match ? `feature-${match[1]}` : 'features';
},
chunks: 'all',
priority: 3,
minSize: 20000
}
}
}
},
// 動態載入路由
resolve: {
alias: {
'@shared': path.resolve(__dirname, 'src/shared'),
'@features': path.resolve(__dirname, 'src/features'),
'@app': path.resolve(__dirname, 'src/app')
}
}
};
// app/router/routes.ts - 懶載入路由設定
import { lazy } from 'react';
// 功能模組懶載入
const UserManagement = lazy(() =>
import('@features/user-management').then(m => ({
default: m.UserManagementPage
}))
);
const Dashboard = lazy(() =>
import('@features/dashboard').then(m => ({
default: m.DashboardPage
}))
);
export const routes = [
{
path: '/users',
component: UserManagement,
preload: () => import('@features/user-management')
},
{
path: '/dashboard',
component: Dashboard,
preload: () => import('@features/dashboard')
}
];
// 路由預載入策略
export function preloadRoute(routePath: string) {
const route = routes.find(r => r.path === routePath);
if (route?.preload) {
route.preload();
}
}
// __tests__/architecture.test.ts
import { analyzeModuleBoundaries } from '../tools/module-analyzer';
describe('模組架構測試', () => {
test('shared 模組不應依賴 features 模組', () => {
const analysis = analyzeModuleBoundaries('./src');
const violations = analysis.findViolations([
'shared -> features 依賴是禁止的'
]);
expect(violations).toHaveLength(0);
});
test('features 模組不應互相依賴', () => {
const analysis = analyzeModuleBoundaries('./src');
const violations = analysis.findCrossFeatureDependencies();
expect(violations).toHaveLength(0);
});
test('模組匯出介面應保持穩定', () => {
const currentExports = analyzeModuleBoundaries('./src').getPublicExports();
const expectedExports = require('../__snapshots__/module-exports.json');
expect(currentExports).toMatchObject(expectedExports);
});
});